#!/usr/bin/env node

/**
 * This script reads and parses all of the source code files, reads the US English
 * translation files and compares the two to identify referenced strings that
 * do not have a corresponding translation.
 * 
 * This script is used in the PR gate to ensure code is not added without the
 * corresponding translations.
 *
 */

const fs = require('fs');
const path = require('path');
const yaml = require('js-yaml');

const base = path.resolve(__dirname, '..');
const srcFolder = path.resolve(base, 'shell');
const pkgFolder = path.resolve(base, 'pkg');

// Simple shell colors
const reset = "\x1b[0m";
const cyan = `\x1b[96m`;
const yellow = `\x1b[33m`;
const white = `\x1b[97m`;
const bold = `\x1b[1m`;
const bg_red = `\x1b[41m`;

const includeExtensions = ['.ts', '.vue', '.js'];
const excludeExtensions = ['.spec.ts', '.test.ts', '.d.ts', 'vue.config.js'];

// Regex list we use to detect uses of localisations
const I18N_REGEX_LIST = [
  /[\.\[\s|=\"]t\(\s*`([0-9a-zA-Z-_\,\."{}\s$\[\]]+)`/g,
  /[\.\[\s]t\(\s*'([0-9a-zA-Z-_\,\."]+)'/g,
  /[\.\[\s|=]t\(\s*"([0-9a-zA-Z-_\,\."]+)"/g,
  /\st\(\s*'([0-9a-zA-Z-_\,\.]+)'/g,
  /{{\s*t\(\s*'([0-9a-zA-Z-_\,\.]+)'/g,
  /{{\s*\$t\(\s*'([0-9a-zA-Z-_\,\.]+)'/g,
  /{{\s*t\(\s*`([0-9a-zA-Z-_\,\.${}\?\s:']+)`/g,
  /\s+k="([0-9a-zA-Z-_\,\.]+)"/g,
  /="t\('([0-9a-zA-Z-_\,\.]+)'/g,
  /[^:^-]label-key="([0-9a-zA-Z-_\,\.]+)"/g,
  /:label="t\('([0-9a-zA-Z-_\,\.]+)'\)"/g,
  /labelKey:\s*['|`]([0-9a-zA-Z-_\,\.{}\s$\[\]]+)['|`]/g,
  /tooltipKey:\s*'([0-9a-zA-Z-_\,\.]+)'/g,
  /placeholderKey:\s*'([0-9a-zA-Z-_\,\.]+)'/g,
    /[^:^-]tooltip-key="([0-9a-zA-Z-_\,\.]+)"/g,
  /tooltip="t\(\s*'([0-9a-zA-Z-_\,\.]+)'/g,
  /placeholder-key="([0-9a-zA-Z-_\,\.]+)"/g,
  /no-rows-key="([0-9a-zA-Z-_\,\.]+)"/g,
  /title-key="([0-9a-zA-Z-_\,\.]+)"/g,
  /\s+add-label="([0-9a-zA-Z-_\,\.]+)"/g,
  /v-t="'([0-9a-zA-Z-_\,\.]+)'"/g,
  /\['i18n\/t'\]\('([0-9a-zA-Z-_\,\.]+)'/g,
  /\['i18n\/withFallback'\]\(`([0-9a-zA-Z-_\,\.$\s{}\"\(\)']+)`/g,
  /\s+k="([0-9a-zA-Z-_\,\.]+)"/g,
  /this\.createOSOption\('([0-9a-zA-Z-_\,\.]+)'/g,
];

// Regex to use to manually indicate that an i18n string is used
const I18N_USES = /^\s*(?:.*)\/\/ i18n-uses\s?([0-9a-zA-Z-_\,\.\"'\s\*\\]*)$/;

// Regex to use to manually indicate that an i18n string should be ignores
const I18N_IGNORE = /^\s*(.*)\/\/ i18n-ignore\s?([0-9a-zA-Z-_\,\.\"'\s\*]*)/;

// -s flag will show the details of all of the unused strings
// -x flag will cause script to return 0, even if there are errors
let showUnused = false;
let doNotReturnError = false;

// Simple arg parsing
if (process.argv.length > 2) {
  process.argv.shift();
  process.argv.shift();

  process.argv.forEach((arg) => {
    if (arg === '-s') {
      showUnused = true;
    } else if (arg === '-x') {
      doNotReturnError = true;
    }
  });
}

function parseReference(ref) {
  if (!ref.includes('$') || ref.startsWith('${')) {
    return ref;
  }

  let out = '';
  let inVar = false;
  let variable = '';
  let vars = 0;

  for (let i=0;i<ref.length;i++) {
    if (inVar) {
      if (ref[i] === '}') {
        inVar = false;
      } else {
        variable += ref[i];
      }
    } else if (ref[i] === '{') {
      inVar = true;
      vars++;
      variable = '';
    } else {
      out += ref[i];
    }
  }

  let p = out.replaceAll('"', '').split('.');

  // Check for patterns like this: resourceDetail.detailTop.${annotationsVisible? 'hideAnnotations' : 'showAnnotations'}
  if (vars === 1 && variable.includes('?')) {
    const options = variable.substr(variable.indexOf('?') + 1);
    const opts = options.split(':').map((o) => o.trim().replaceAll('\'', ''));

    if (opts.length === 2) {
      const a  = p.map((o) => o === '$' ? opts[0] : o).join('.');
      const b  = p.map((o) => o === '$' ? opts[1] : o).join('.');

      return [a, b];
    }
  }

  p = p.map((a) => a.startsWith('$') ? `.*${ a.substr(1) }` : a);

  return new RegExp(p.join('\\.'), 'g');
}

function readAndParseTranslations(filePath) {
  const data = fs.readFileSync(filePath, 'utf8');

  try {
    const i18n = yaml.load(fs.readFileSync(filePath), 'utf8');

    return parseTranslations(i18n);
  } catch (e) {
    console.log('Can not read i18n file');  // eslint-disable-line no-console
    console.log(e);  // eslint-disable-line no-console
    process.exit(1);
  }
}

function parseTranslations(obj, parent) {
  let res = {};
  Object.keys(obj).forEach((key) => {
    const v = obj[key];
    const pKey = parent ? `${parent}.${key}` : key;

    if (v === null) {
      // ignore
    } else if (typeof v === 'object') {
      res = {
        ...res,
        ...parseTranslations(v, pKey)
      }
    } else {
      // Ensure empty strings work
      res[pKey] = v.length === 0 ? '[empty]' : v;
    }
  });

  return res;
}

function makeRegex(str) {
  if (str.startsWith('/') && str.endsWith('/')) {
    return new RegExp(str.substr(1, str.length - 2), 'g');
  } else {
    // String
    // Support .* for a simple wildcard match
    if (str.includes('*')) {
      const parts = str.split('.');
      const p = parts.map((s, i) => s === '*' ? (i === parts.length -1 ? '.*' : '[^\.]*') : s);

      return new RegExp(`^${ p.join('\\.') }$`);
    } else {
      return str;
    }
  }
}

function doesMatch(stringOrRegex, str) {
  if (typeof stringOrRegex === 'string') {
    return str === stringOrRegex;
  } else {
    return str.match(stringOrRegex);
  }
}

function processSourceFile(filePath) {
  const data = fs.readFileSync(filePath, 'utf8');

  let lineNum = 0;
  const strings = {};
  const fileIgnores = [];
  const fileUses = [];

  let ignoreNextLine = false;
  let ignoreThisLine = false;

  // Process file line by line
  data.split('\n').forEach((line) => {
    lineNum++;

    // Make a note of any uses comments - this allows a file to indicate any strings it uses that
    // can not be determined easily - typically because of dynamic look-up
    const uses = line.match(I18N_USES);

    if (uses) {
      const list = uses[1].split(',').map((i) => i.trim());

      fileUses.push(...list.map((i) => makeRegex(i)));
    }

    ignoreThisLine = false;

    const ignore = line.match(I18N_IGNORE);

    if (ignore && ignore.length === 3) {
      // 3 possible usages:
      // 1. All matches empty = ignore next line
      // 2. Last match set and first match empty - ignore globally
      // 3. First match set and Last match empty - ignore this line
      const before = ignore[1];
      const after = ignore[2];

      if (!before.length && !after.length) {
        // Ignore the next line
        ignoreNextLine = true;
      } else if (before.length && !after.length) {
        // Ignore this line
        ignoreThisLine = true;
      } else if (!before.length && after.length) {
        const list = after.split(',').map((i) => i.trim());

        fileIgnores.push(...list.map((i) => makeRegex(i)));
      }
    }

    // Ignore comments
    if (ignoreThisLine || !line.trim().startsWith('//')) {
      if (ignoreNextLine) {
        ignoreNextLine = false;
      } else {
        // Loop through the regexes
        I18N_REGEX_LIST.forEach((r) => {
          const m = [...line.matchAll(r)];

          m.forEach((item) => {
            if (item) {
              const key = item[1].replaceAll('"', '');
              const parsed = parseReference(key);

              if (typeof parsed === 'string' || Array.isArray(parsed)) {
                const items = Array.isArray(parsed) ? parsed: [parsed];

                items.forEach((k) => {
                  // Ignore?
                  const ignore = !!fileIgnores.find((i) => doesMatch(i, k));

                  if (!ignore && !k.startsWith('${')) {
                    strings[k] = {
                      file: filePath,
                      line: lineNum,
                      col: item.index
                    };
                  }
                });
              } else {
                // regex
                fileUses.push(parsed);
              }
            }
          });
        });
      }
    }
  });

  return {
    uses:    fileUses,
    strings
  };
}

function findCodeFiles(result, folder) {
  // Find all of the test files
  fs.readdirSync(folder).forEach((file) => {
    const filePath = path.resolve(folder, file)
    const isFolder = fs.lstatSync(filePath).isDirectory();

    // Ignore hidden folders and files
    if (!file.startsWith('.')) {
      if (isFolder && file !== '__tests__' && file !== 'node_modules') {
        findCodeFiles(result, filePath);
      } else {
        const exclude = excludeExtensions.find((ext) => file.endsWith(ext));

        if (!exclude) {
          const include = includeExtensions.find((ext) => file.endsWith(ext));

          if (include) {
            result.push(processSourceFile(filePath));
          }
        }
      }
    }
  });
}

function loadI18nFiles(folder) {
  let res = {};

  // Find all of the test files
  fs.readdirSync(folder).forEach((file) => {
    const filePath = path.resolve(folder, file)
    const isFolder = fs.lstatSync(filePath).isDirectory();

    if (isFolder) {
      res = {
        ...res,
        ...loadI18nFiles(filePath)
      };
    } else if (file === 'en-us.yaml') {
      console.log(` ... ${ path.relative(base, filePath) }`);

      const translations = readAndParseTranslations(filePath);

      res = {
        ...res,
        ...translations
      };
    }
  });

  return res;
}

console.log('======================================'); // eslint-disable-line no-console
console.log(`${cyan}Checking source files for i18n strings${reset}`); // eslint-disable-line no-console
console.log('======================================'); // eslint-disable-line no-console

console.log(''); // eslint-disable-line no-console
console.log('Reading translation files:'); // eslint-disable-line no-console

let i18n = loadI18nFiles(srcFolder);

i18n = { ...i18n, ...loadI18nFiles(pkgFolder) };

console.log(`Read  ${cyan}${ Object.keys(i18n).length }${reset} translations`); // eslint-disable-line no-console

const files = [];
findCodeFiles(files, srcFolder);
findCodeFiles(files, pkgFolder);

console.log(`Read  ${cyan}${ files.length }${reset} source files`); // eslint-disable-line no-console

const references = {};
let uses = [];

files.forEach((f) => {
  uses.push(...f.uses);
});

files.forEach((f) => {
  Object.keys(f.strings).forEach((key) => {
    if (!references[key]) {
      references[key] = [];
    }

    references[key].push(f.strings[key]);
  });
});

console.log(`Found ${cyan}${ Object.keys(references).length }${reset} i18n references in code`); // eslint-disable-line no-console

let unused = 0;
const unusedKeys = [];

// Look for translations that are not used
Object.keys(i18n).forEach((str) => {
  // Should it be ignored?
  const ignore = !!uses.find((i) => doesMatch(i, str));

  if (!ignore && !references[str]) {
    unused++;
    unusedKeys.push(str);
  }
});

if (unusedKeys.length) {
  if (showUnused) {
    console.log(''); // eslint-disable-line no-console
    console.log('Found the following unused translation strings:'); // eslint-disable-line no-console
    console.log(''); // eslint-disable-line no-console

    unusedKeys.sort();
    unusedKeys.forEach((k) => console.log(k));
    console.log(''); // eslint-disable-line no-console
  }

  console.log(`Found ${yellow}${bold}${unused}${reset} unused translation strings`); // eslint-disable-line no-console
}

// Apply the ignores
const filteredUses = Object.keys(references).filter((r) => !uses.find((i) => r.match(i)));
const missingTranslations = filteredUses.filter((r) => !i18n[r]);

if (missingTranslations.length) {
  console.log(''); // eslint-disable-line no-console
  console.log(`${bold}Found the following references without a matching translation:${reset}`); // eslint-disable-line no-console
  console.log(''); // eslint-disable-line no-console
  console.log(`${ 'Translation Key'.padEnd(64) } Location`); // eslint-disable-line no-console
  console.log(`${ ''.padEnd(64, '-') } ${ ''.padEnd(32, '-') }`); // eslint-disable-line no-console

  missingTranslations.forEach((key) => {
    references[key].forEach((ref) => {
      console.log(`${yellow}${ key.padEnd(64) } ${cyan}${ path.relative(base, ref.file) }${reset} (Line: ${ ref.line }, Col: ${ ref.col })`); // eslint-disable-line no-console
    });
  });

  console.log(''); // eslint-disable-line no-console
  console.log(`${bold}${bg_red}${white}Error: Found ${missingTranslations.length} i18n references without a matching translation${reset}`); // eslint-disable-line no-console
}

console.log(''); // eslint-disable-line no-console

if (doNotReturnError) {
  if (missingTranslations.length) {
    console.log(`${bold}${bg_red}${white}-x specified - returning 0 from script even though errors were found${reset}`); // eslint-disable-line no-console
  }
} else {
  process.exit(missingTranslations.length == 0 ? 0 : 1);
}
